Skip to content

Prevent stale 404s for files written by another realm-server instance - #5923

Open
jurgenwerk wants to merge 5 commits into
mainfrom
cs-12032-peer-readdir-on-file-change
Open

Prevent stale 404s for files written by another realm-server instance#5923
jurgenwerk wants to merge 5 commits into
mainfrom
cs-12032-peer-readdir-on-file-change

Conversation

@jurgenwerk

@jurgenwerk jurgenwerk commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

I was testing the AI assistant on staging and been getting wild sporadic errors related to missing files, duplicated files, bad patches, missing diffs (none of this happened with local testing).

It turns out this is because staging runs two realm-server tasks behind the LB, and each task keeps its own short-term memory of what is on the disk. When one task writes a file, the other task's memory is not updated; it keeps believing the file does not exist until that memory expires 30–60 seconds later.

Claude's summary:

Prevent a realm-server task from saying a file does not exist right after another task wrote it.

The problem: staging runs two realm-server tasks that share one disk. Each task's operating system remembers "this file is not there" from earlier checks. When one task writes a file, the other task keeps giving that old answer for up to a minute. The AI assistant checks a file name before writing it, so it always hits this: it writes a file, is told the file is missing, and writes it again — duplicate files and confusing errors.

The fix: each task already gets a message when the other task writes a file. On that message, the task now lists the file's folder. Listing a folder makes the operating system look at the disk again and forget the old answer.

How we verified it: a probe script on staging asks both tasks about a file that does not exist yet, writes it, and then polls both tasks. Before the fix, the task that did not write the file answered 404 for 50 seconds. After deploying this branch to staging, the same probe got zero 404s — both tasks served the file in under a second.

🤖 Generated with Claude Code

On a multi-instance realm-server sharing one EFS volume, a peer that had
looked up a path before it existed keeps answering 404 for it until the
kernel's directory attribute cache expires (~50s measured on staging).
Re-listing the parent directory when the write notification arrives makes
the kernel re-read the directory and drop the stale negative entry.

CS-12032

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Host Test Results

    1 files  ±0      1 suites  ±0   2h 27m 38s ⏱️ - 9m 57s
4 595 tests ±0  4 581 ✅ ±0  14 💤 ±0  0 ❌ ±0 
4 610 runs  ±0  4 596 ✅ ±0  14 💤 ±0  0 ❌ ±0 

Results for commit 1dae480. ± Comparison against earlier commit a522e1f.

Realm Server Test Results

    1 files  ± 0      1 suites  ±0   26m 20s ⏱️ + 3m 55s
2 338 tests  - 19  2 338 ✅  - 19  0 💤 ±0  0 ❌ ±0 
2 421 runs   - 19  2 421 ✅  - 19  0 💤 ±0  0 ❌ ±0 

Results for commit 4ded68a. ± Comparison against earlier commit 1dae480.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds cross-instance NFS directory-cache refreshes so peer realm servers can see newly written files promptly.

Changes:

  • Adds Realm.refreshDirectoryView().
  • Refreshes directories after per-path notifications.
  • Tests dispatch, wildcard exclusion, and failure handling.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
packages/runtime-common/realm.ts Adds directory refresh logic.
packages/realm-server/lib/realm-file-changes-listener.ts Triggers refreshes from notifications.
packages/realm-server/tests/realm-file-changes-listener-test.ts Tests listener behavior.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread packages/runtime-common/realm.ts Outdated
async refreshDirectoryView(path: LocalPath): Promise<void> {
let separator = path.lastIndexOf('/');
let directory = separator === -1 ? '' : path.slice(0, separator);
for await (let _entry of this.#adapter.readdir(directory)) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] (Written by Claude on Matic's behalf.) Fixed: the refresh now lists every ancestor from the realm root down to the immediate parent (ancestorDirectories), so a parent directory the peer had cached as absent is refreshed before its children are looked up. Covered by the new directory-view-refresher-test.ts.

Comment on lines +108 to +110
// Fire-and-forget: the NOTIFY handler stays synchronous, and a failed
// directory listing only means the kernel cache expires on its own.
realm.refreshDirectoryView(parsed.path).catch((err: unknown) => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] (Written by Claude on Matic's behalf.) Fixed both parts: NodeAdapter.readdir now uses fs.promises-style readdir (the sync helper it used is removed), and the refresh logic moved into DirectoryViewRefresher, which coalesces per directory — while a listing is in flight, later requests share it and at most one follow-up is queued behind it. A batch write into one directory therefore costs a bounded number of listings instead of one per file; the test asserts listings never overlap and that five concurrent refreshes produce fewer listings than refreshes.

Comment thread packages/realm-server/tests/realm-file-changes-listener-test.ts Outdated
…ronously

Address review: a peer that cached a parent directory as absent needs the
ancestors re-listed too; batch writes emit one notification per file, so
listings of one directory are coalesced (one in flight, one queued);
NodeAdapter.readdir now uses the async fs API so the refresh and the request
path do not block the event loop on a network filesystem.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jurgenwerk jurgenwerk changed the title Refresh a peer realm-server's directory view when another instance writes a file Prevent stale 404s for files written by another realm-server instance Aug 28, 2026
@jurgenwerk
jurgenwerk marked this pull request as ready for review August 31, 2026 08:07
@jurgenwerk
jurgenwerk requested a review from a team August 31, 2026 08:07
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Aug 31, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-08-31T08:11:54.356691Z 65b7292 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 65b7292a50

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +57 to +58
if (entry.queued) {
return entry.queued;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Queue another listing during the follow-up refresh

When three notifications for the same directory overlap, the first starts current, the second creates queued, and any notification arriving after that—even while the queued listing is already running—takes this branch and schedules nothing after it. If that already-started NFS listing did not observe the latest write, its negative entry remains cached and the peer can continue returning the stale 404 this change is intended to eliminate. Promote the follow-up to the current listing when it starts so arrivals during it can queue one additional refresh.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] (Written by Claude on Matic's behalf.) Fixed: #startListing now installs the follow-up as the running listing when it begins (replacing the predecessor entry), so a request that arrives during it queues one more listing instead of sharing one that started before its write. New test covers the three-arrival scenario.

… anew

A request arriving while the follow-up listing was already running used to
share it, although that listing started before the write the request needs
revealed. The follow-up now replaces the running listing when it starts, so
a later request queues one more listing behind it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@habdelra habdelra left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] This review went after the coalescing state machine under concurrent notifications, the sync→async readdir change and its remaining callers, and what the notification surface does and doesn't reach. It did not attempt to re-verify the NFS/EFS mechanism itself — the staging probe is the evidence for that.

No blocking issues. I worked the DirectoryViewRefresher interleavings by hand and by running the class directly, including the late-arrival case the last commit addressed, and the per-request ordering guarantee holds: a request never joins a listing that started before it, and never lists an ancestor before the one above it has finished. Four non-blocking asks below; the substantive one is whether the bulk/wildcard branch is meant to stay uncovered.

  1. refresh() abandons the remaining ancestors when one listing fails, so a transient failure on the realm root cancels the refresh of the parent that actually matters — see the thread on refresh in directory-view-refresher.ts, which also covers the missing timeout.
  2. The wildcard branch leaves peers with a stale view of a republished realm's whole tree — see the thread on clearLocalSourceCaches in realm-file-changes-listener.ts.
  3. The coalescing test's < names.length assertions pass under degraded coalescing; the root count is deterministically 2 — see the thread in directory-view-refresher-test.ts.
  4. Every write costs a root listing on every instance including the writer — see the thread on ancestorDirectories.

Adjacent, out of scope: NodeAdapter.readdir's opts.create branch calls ensureDirSync(path) with the realm-relative path rather than absolutePath, so it would create a directory relative to the process cwd. No caller in the repo passes create, so it's inert today — worth fixing whenever the first one is wired up, not here.


Generated by Claude Code

Comment on lines +41 to +45
async refresh(path: LocalPath): Promise<void> {
for (let directory of ancestorDirectories(path)) {
await this.#list(directory);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] One failing ancestor cancels the rest of the chain, including the immediate parent — the directory whose refresh is the entire point of the call.

I ran this against the real class: refresh('a/b/c.json') where the listing of '' rejects lists '' and nothing else — a and a/b never run, and the listener's .catch only logs a warning, so the stale 404 stands for the full attribute-cache TTL. The realm root is the ancestor least likely to need refreshing (it always exists) and the one whose failure is most expensive, since it is listed first. Continuing is strictly better: when a/b already exists in this instance's view — the common case — listing it alone is what clears the file's negative entry.

async refresh(path: LocalPath): Promise<void> {
  let errors: unknown[] = [];
  for (let directory of ancestorDirectories(path)) {
    try {
      await this.#list(directory);
    } catch (err) {
      errors.push(err);
    }
  }
  if (errors.length) {
    throw errors.length === 1 ? errors[0] : new AggregateError(errors);
  }
}

Related, same containment gap on a different axis: #list has no timeout, and a listing that never settles is never replaced in #listings, so every later refresh for that directory queues behind it for the life of the process rather than for the duration of the hang. I confirmed that too — with a lister returning a never-resolving promise, the second refresh's listing never starts. A hard-mounted NFS/EFS hanging is the failure mode worth defending against here; reads still self-heal on the kernel TTL, so this is durability of the refresher, not correctness.

Regression (introduced here), non-blocking.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] (Written by Claude on Matic's behalf.) Fixed both parts: refresh() now collects per-ancestor errors and keeps walking (single error re-thrown as-is, several as an AggregateError), and every listing is wrapped in a timeout (default 15 s, constructor-injectable) that releases the per-directory slot so a hung filesystem call cannot block later refreshes for the life of the process. The timeout does not cancel the underlying listing; a fresh one may overlap it, which is safe for reads. Tests cover the failing-root chain and the hung-listing recovery.

@@ -102,6 +105,13 @@ export class RealmFileChangesListener {
realm.clearLocalSourceCaches();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Is the bulk branch meant to stay uncovered? It is the same stale-view bug over a whole realm instead of one path.

handle-publish-realm.ts swaps the realm directory on disk and then calls clearLocalSourceCachesAndBroadcast(). That drops peers' byte caches but leaves their kernel view of the swapped tree untouched, so a peer that still has the republished realm mounted can 404 every file the new snapshot added, for the same tens of seconds. Unpublish and delete are covered incidentally — the realm is unmounted there, so lookupMountedRealm returns undefined and the notification is dropped before this branch ever runs — but republish is not.

Either wire something in here or say in the PR that it's a follow-up; as it stands the wildcard branch reads as handled. realm.refreshDirectoryView('') is a one-line partial that covers top-level files (ancestorDirectories('') is just ['']); covering nested files needs a recursive walk, which is a real cost decision and probably its own change.

Pre-existing, now load-bearing — this PR establishes the mechanism that makes the gap visible. Non-blocking.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] (Written by Claude on Matic's behalf.) Follow-up, deliberately: covering republish needs a recursive walk of the swapped tree, which is a cost decision that deserves its own change. The listener's wildcard comment now says explicitly that the kernel view is NOT refreshed there and what the consequence is, so the branch no longer reads as handled.

Comment on lines +55 to +69
let rootCoalesced = count('') < names.length;
let dirCoalesced = count('PersonCard') < names.length;
assert.true(
rootCoalesced,
`root listed fewer times than refreshes (${count('')})`,
);
assert.true(
dirCoalesced,
`PersonCard listed fewer times than refreshes (${count('PersonCard')})`,
);
let followUpRan = count('') >= 2;
assert.true(
followUpRan,
'a refresh that arrived mid-listing got a follow-up listing',
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] These three assertions are weaker than the contract they name — count(dir) < names.length passes at 4 listings out of 5 refreshes, so a regression that degraded coalescing to one-follow-up-per-notification stays green.

The root count here is structurally forced, not timing-dependent: names.map(...) calls refresh synchronously, and each refresh runs to its first await this.#list('') synchronously, so the first call starts a listing and the other four all attach to the same pending. I ran this scenario against the refresher directly — 20 trials at each of 0/1/5/20 ms listing latency — and the root count was exactly 2 every time. Assert that instead; it subsumes both rootCoalesced and followUpRan:

assert.strictEqual(count(''), 2, 'root listed once, then one follow-up for the four that arrived during it');
assert.true(count('PersonCard') <= 3, `PersonCard listings stayed bounded (${count('PersonCard')})`);

PersonCard came back as 3 in all 80 runs, but unlike root it depends on the interleaving of the root follow-up and the first PersonCard listing, so a bound rather than an equality is the honest assertion there.

Regression in test strength (this file is new), non-blocking.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] (Written by Claude on Matic's behalf.) Applied as suggested: the root count is asserted as exactly 2 with a comment explaining why it is structurally forced, and PersonCard as a ≤3 bound.

Comment on lines +84 to +91
export function ancestorDirectories(path: LocalPath): LocalPath[] {
let directories: LocalPath[] = [''];
let segments = path.split('/');
for (let i = 1; i < segments.length; i++) {
directories.push(segments.slice(0, i).join('/'));
}
return directories;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Worth confirming the per-write listing budget is what you want: every single-path notification now lists the realm root plus each intermediate directory, on every instance that has the realm mounted — including the instance that emitted the NOTIFY, since pg_notify goes out on a pooled connection and comes back on this process's own dedicated LISTEN connection. Realm.clearLocalSourceCachesAndBroadcast's comment already records that self-receive happens and calls it free, which it was for an idempotent in-memory clear. It isn't free for an EFS round trip, and the writer's own view is the one that's already correct.

The root listing is the expensive half on a user realm whose cards sit at the top level, and it's only needed when the write created a new intermediate directory. A cheaper shape: have the lister hand back the entry names, list the immediate parent first, and walk the rest of the chain only when the written basename isn't in it — which also covers the stale-parent case, since a parent that's still cached as absent lists as empty.

Non-blocking, and a decision rather than a defect — if the current budget is deliberate, a line in the header comment saying so would settle it.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] (Written by Claude on Matic's behalf.) Kept the current budget and documented it as deliberate in the header comment: realm trees are shallow, bursts coalesce per directory, and the walk-up-only-when-missing shape would trade that simplicity for savings mostly on the root listing. Worth revisiting if per-write listing cost ever shows up in the prerender/indexing telemetry.

Address review: one failing ancestor no longer cancels the rest of the
chain (errors are collected and re-thrown after the walk); a listing that
never settles is timed out so it stops occupying the per-directory slot;
the coalescing test asserts the structurally exact root count; the header
comments record the per-write listing budget as deliberate and the
wildcard branch's unrefreshed kernel view as a known follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants